import { manifest } from "@/env.js"
import { readPrompt, readPromptFile, resolvePromptReplaces } from "@/prompts.js"
import { beforeEach, describe, expect, test, vi } from "vitest"

// Hoisted mocks.
const mockReadFileSync = vi.hoisted(() => vi.fn())
const mockResolve = vi.hoisted(() => vi.fn((...args: string[]) => args.join("/")))
const mockJoin = vi.hoisted(() => vi.fn((...args: string[]) => args.join("/")))

vi.mock("node:fs", () => ({ readFileSync: mockReadFileSync }))
vi.mock("node:path", () => ({
  resolve: mockResolve,
  join: mockJoin,
}))

beforeEach(() => {
  vi.clearAllMocks()
  // Reset mocks to default behavior
  mockResolve.mockImplementation((...args) => args.join("/"))
  mockJoin.mockImplementation((...args) => args.join("/"))
  mockReadFileSync.mockReturnValue("file content")
})

describe("readPrompt", () => {
  test("non-dev mode with default options", async () => {
    vi.doMock("@/env.ts", () => ({ dev: false, manifest }))
    vi.resetModules()

    const name = "testPrompt"
    const expectedSuffixName = "testPrompt.md"
    const expectedResourcesDir = "node_modules/primaluce/docs/resources"
    const expectedFullPath = `${expectedResourcesDir}/${expectedSuffixName}`

    mockResolve.mockReturnValue(expectedResourcesDir)
    mockJoin.mockReturnValueOnce(expectedFullPath)

    readPrompt(name)

    expect(mockResolve).toHaveBeenCalledWith("node_modules", "primaluce", "docs", "resources")
    expect(mockJoin).toHaveBeenCalledWith(expectedResourcesDir, expectedSuffixName)
    expect(mockReadFileSync).toHaveBeenCalledWith(expectedFullPath, "utf8")
  })

  test("dev mode with custom language", async () => {
    vi.doMock("@/env.ts", () => ({ dev: true }))
    vi.resetModules()

    // Must mock this to override.
    const { readPrompt } = await import("./resolve.js")

    const name = "devPrompt"
    const expectedSuffixName = "devPrompt.md"
    const expectedResourcesDir = "docs/resources"
    const expectedLangDir = `${expectedResourcesDir}/en`
    const expectedFullPath = `${expectedLangDir}/${expectedSuffixName}`

    mockResolve.mockReturnValue(expectedResourcesDir)
    // Simulate two join calls: first join(promptsDir, language), second join(result, fileName)
    mockJoin
      .mockReturnValueOnce(expectedLangDir) // join(promptsDir, language)
      .mockReturnValueOnce(expectedFullPath) // join(join(promptsDir, language), fileName)

    readPrompt(name, { language: "en" })

    expect(mockResolve).toHaveBeenCalledWith(expect.any(String), "..", "..", "docs", "resources")
    expect(mockJoin).toHaveBeenNthCalledWith(1, expectedResourcesDir, "en")
    expect(mockJoin).toHaveBeenNthCalledWith(2, expectedLangDir, expectedSuffixName)
    expect(mockReadFileSync).toHaveBeenCalledWith(expectedFullPath, "utf8")
  })

  test("fallback to zh when language file not found", async () => {
    vi.doMock("@/env.ts", () => ({ dev: true, manifest }))
    vi.resetModules()

    const name = "fallbackPrompt"
    const expectedSuffixName = "fallbackPrompt.md"
    const expectedResourcesDir = "docs/resources"
    const expectedLangDir = `${expectedResourcesDir}/en`
    const expectedFullPath = `${expectedLangDir}/${expectedSuffixName}`
    const expectedZhPath = `${expectedResourcesDir}/${expectedSuffixName}`

    mockResolve.mockReturnValue(expectedResourcesDir)
    // Simulate join calls:
    // 1. join(promptsDir, language) -> expectedLangDir
    // 2. join(expectedLangDir, fileName) -> expectedFullPath
    // 3. join(promptsDir, fileName) -> expectedZhPath (fallback)
    mockJoin
      .mockReturnValueOnce(expectedLangDir) // join(promptsDir, language)
      .mockReturnValueOnce(expectedFullPath) // join(join(promptsDir, language), fileName)
      .mockReturnValueOnce(expectedZhPath) // join(promptsDir, fileName) for fallback

    // Simulate file not found for English, success for zh
    mockReadFileSync
      .mockImplementationOnce(() => {
        throw new Error("File not found")
      })
      .mockReturnValueOnce("zh content")

    const result = readPrompt(name, { language: "en" })

    expect(mockReadFileSync).toHaveBeenCalledTimes(2)
    expect(mockReadFileSync).toHaveBeenNthCalledWith(1, expectedFullPath, "utf8")
    expect(mockReadFileSync).toHaveBeenNthCalledWith(2, expectedZhPath, "utf8")
    expect(result).toBe("zh content")
  })

  test("uses custom encoding and replaces", async () => {
    vi.doMock("@/env.ts", () => ({ dev: false, manifest }))
    vi.resetModules()

    const name = "customPrompt"
    const expectedSuffixName = "customPrompt.md"
    const expectedResourcesDir = "node_modules/primaluce/docs/resources"
    const expectedFullPath = `${expectedResourcesDir}/${expectedSuffixName}`

    mockResolve.mockReturnValue(expectedResourcesDir)
    // For zh language, join is called once with (resourcesDir, fileName)
    mockJoin.mockImplementation(() => expectedFullPath)

    mockReadFileSync.mockReturnValue("Hello ${name}")

    const result = readPrompt(name, {
      encoding: "latin1",
      replaces: { name: "World" },
    })

    expect(mockJoin).toHaveBeenCalledWith(expectedResourcesDir, expectedSuffixName)
    expect(mockReadFileSync).toHaveBeenCalledWith(expectedFullPath, "latin1")
    expect(result).toBe("Hello World")
  })
})

describe("readPromptFile", () => {
  test("reads file successfully for given language", () => {
    const promptsDir = "/some/dir"
    const language = "en"
    const fileName = "test.md"
    const encoding = "utf8"
    const expectedLangDir = `${promptsDir}/${language}`
    const expectedPath = `${expectedLangDir}/${fileName}`
    const expectedContent = "file content"

    // First join for language subdirectory, second join for full path
    mockJoin
      .mockReturnValueOnce(expectedLangDir) // join(promptsDir, language)
      .mockReturnValueOnce(expectedPath) // join(join(promptsDir, language), fileName)
    mockReadFileSync.mockReturnValue(expectedContent)

    const result = readPromptFile(promptsDir, language, fileName, encoding)

    expect(mockJoin).toHaveBeenNthCalledWith(1, promptsDir, language)
    expect(mockJoin).toHaveBeenNthCalledWith(2, expectedLangDir, fileName)
    expect(mockReadFileSync).toHaveBeenCalledWith(expectedPath, encoding)
    expect(result).toBe(expectedContent)
  })

  test("falls back to zh when file not found and language not zh", () => {
    const promptsDir = "/some/dir"
    const language = "en"
    const fileName = "test.md"
    const encoding = "utf8"
    const expectedLangDir = `${promptsDir}/${language}`
    const expectedPath = `${expectedLangDir}/${fileName}`
    const zhPath = `${promptsDir}/${fileName}`
    const zhContent = "zh content"

    mockJoin
      .mockReturnValueOnce(expectedLangDir) // join(promptsDir, language)
      .mockReturnValueOnce(expectedPath) // join(join(promptsDir, language), fileName)
      .mockReturnValueOnce(zhPath) // join(promptsDir, fileName) for fallback
    mockReadFileSync
      .mockImplementationOnce(() => {
        throw new Error("File not found")
      })
      .mockReturnValueOnce(zhContent)

    const result = readPromptFile(promptsDir, language, fileName, encoding)

    expect(mockJoin).toHaveBeenNthCalledWith(1, promptsDir, language)
    expect(mockJoin).toHaveBeenNthCalledWith(2, expectedLangDir, fileName)
    expect(mockJoin).toHaveBeenNthCalledWith(3, promptsDir, fileName)
    expect(mockReadFileSync).toHaveBeenNthCalledWith(1, expectedPath, encoding)
    expect(mockReadFileSync).toHaveBeenNthCalledWith(2, zhPath, encoding)
    expect(result).toBe(zhContent)
  })

  test("does not fall back when language is zh", () => {
    const promptsDir = "/some/dir"
    const language = "zh"
    const fileName = "test.md"
    const encoding = "utf8"
    const expectedPath = `${promptsDir}/${fileName}`

    mockJoin.mockReturnValue(expectedPath)
    mockReadFileSync.mockImplementation(() => {
      throw new Error("File not found")
    })

    expect(() => readPromptFile(promptsDir, language, fileName, encoding)).toThrow("File not found")
    expect(mockJoin).toHaveBeenCalledWith(promptsDir, fileName)
    expect(mockReadFileSync).toHaveBeenCalledWith(expectedPath, encoding)
  })

  test("throws error when fallback file also not found", () => {
    const promptsDir = "/some/dir"
    const language = "en"
    const fileName = "test.md"
    const encoding = "utf8"
    const expectedPath = `${promptsDir}/${language}/${fileName}`
    const zhDir = `${promptsDir}/zh`
    const zhPath = `${zhDir}/${fileName}`

    mockJoin
      .mockReturnValueOnce(expectedPath)
      .mockReturnValueOnce(zhDir)
      .mockReturnValueOnce(zhPath)
    mockReadFileSync.mockImplementation(() => {
      throw new Error("File not found")
    })

    expect(() => readPromptFile(promptsDir, language, fileName, encoding)).toThrow("File not found")
    expect(mockReadFileSync).toHaveBeenCalledTimes(2)
  })
})

describe("resolvePromptReplaces", () => {
  test("replaces single placeholder", () => {
    const raw = "Hello ${name}"
    const replaces = { name: "World" }
    const result = resolvePromptReplaces(raw, replaces)
    expect(result).toBe("Hello World")
  })

  test("replaces multiple placeholders", () => {
    const raw = "${greeting}, ${name}!"
    const replaces = { greeting: "Hello", name: "Alice" }
    const result = resolvePromptReplaces(raw, replaces)
    expect(result).toBe("Hello, Alice!")
  })

  test("replaces multiple occurrences", () => {
    const raw = "${word} ${word} ${word}"
    const replaces = { word: "repeat" }
    const result = resolvePromptReplaces(raw, replaces)
    expect(result).toBe("repeat repeat repeat")
  })

  test("returns raw when no replaces", () => {
    const raw = "Hello ${name}"
    const replaces = {}
    const result = resolvePromptReplaces(raw, replaces)
    expect(result).toBe(raw)
  })

  test("handles placeholders with special characters", () => {
    const raw = "${user.name} is ${status}"
    const replaces = { "user.name": "john.doe", status: "active" }
    const result = resolvePromptReplaces(raw, replaces)
    expect(result).toBe("john.doe is active")
  })

  test("handles replacement containing $", () => {
    const raw = "Price: ${amount}"
    const replaces = { amount: "$100" }
    const result = resolvePromptReplaces(raw, replaces)
    expect(result).toBe("Price: $100")
  })
})
